/*
 * Copyright (c) 2012-2017 Red Hat, Inc.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   Red Hat, Inc. - initial API and implementation
 */
package org.eclipse.che.api.project.server;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.eclipse.che.api.core.ConflictException;
import org.eclipse.che.api.core.ForbiddenException;
import org.eclipse.che.api.core.NotFoundException;
import org.eclipse.che.api.core.ServerException;
import org.eclipse.che.api.core.model.project.NewProjectConfig;
import org.eclipse.che.api.core.model.project.ProjectConfig;
import org.eclipse.che.api.core.notification.EventService;
import org.eclipse.che.api.project.server.handlers.ProjectHandlerRegistry;
import org.eclipse.che.api.project.server.handlers.ProjectInitHandler;
import org.eclipse.che.api.project.server.type.BaseProjectType;
import org.eclipse.che.api.project.server.type.ProjectTypeRegistry;
import org.eclipse.che.api.vfs.Path;
import org.eclipse.che.api.vfs.VirtualFile;
import org.eclipse.che.api.vfs.VirtualFileSystem;
import org.eclipse.che.api.vfs.VirtualFileSystemProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Stores internal representation of Projects registered in the Workspace Agent.
 *
 * @author gazarenkov
 */
@Singleton
public class ProjectRegistry {
  private static final Logger LOG = LoggerFactory.getLogger(ProjectRegistry.class);

  private final Map<String, RegisteredProject> projects;
  private final WorkspaceProjectsSyncer workspaceHolder;
  private final VirtualFileSystem vfs;
  private final ProjectTypeRegistry projectTypeRegistry;
  private final ProjectHandlerRegistry handlers;
  private final FolderEntry root;
  private final EventService eventService;

  private boolean initialized;

  @Inject
  public ProjectRegistry(
      WorkspaceProjectsSyncer workspaceHolder,
      VirtualFileSystemProvider vfsProvider,
      ProjectTypeRegistry projectTypeRegistry,
      ProjectHandlerRegistry handlers,
      EventService eventService)
      throws ServerException {
    this.eventService = eventService;
    this.projects = new ConcurrentHashMap<>();
    this.workspaceHolder = workspaceHolder;
    this.vfs = vfsProvider.getVirtualFileSystem();
    this.projectTypeRegistry = projectTypeRegistry;
    this.handlers = handlers;
    this.root = new FolderEntry(vfs.getRoot());
  }

  @PostConstruct
  public void initProjects()
      throws ConflictException, NotFoundException, ServerException, ForbiddenException {

    List<? extends ProjectConfig> projectConfigs = workspaceHolder.getProjects();

    // take all the projects from ws's config
    for (ProjectConfig projectConfig : projectConfigs) {
      final String path = projectConfig.getPath();
      final VirtualFile vf = vfs.getRoot().getChild(Path.of(path));
      final FolderEntry projectFolder = ((vf == null) ? null : new FolderEntry(vf, this));

      putProject(projectConfig, projectFolder, false, false);
    }

    initUnconfiguredFolders();

    initialized = true;

    for (RegisteredProject project : projects.values()) {
      // only for projects with sources
      if (project.getBaseFolder() != null) {
        fireInitHandlers(project);
      }
    }
  }

  /** @return all the registered projects */
  public List<RegisteredProject> getProjects() {
    checkInitializationState();

    initUnconfiguredFolders();

    return new ArrayList<>(projects.values());
  }

  /**
   * @param projectPath project path
   * @return project or null if not found
   */
  public RegisteredProject getProject(String projectPath) {
    checkInitializationState();

    initUnconfiguredFolders();

    return projects.get(absolutizePath(projectPath));
  }

  /**
   * @param parentPath parent path
   * @return list projects of pojects
   */
  public List<String> getProjects(String parentPath) {
    checkInitializationState();

    initUnconfiguredFolders();

    final Path root = Path.of(absolutizePath(parentPath));

    return projects
        .keySet()
        .stream()
        .filter(key -> Path.of(key).isChild(root))
        .collect(Collectors.toList());
  }

  /**
   * @param path - path of child project
   * @return the project owned this path.
   */
  public RegisteredProject getParentProject(String path) {
    checkInitializationState();

    // return this if a project
    if (getProject(path) != null) {
      return getProject(path);
    }

    // otherwise try to find matched parent
    Path test;
    while ((test = Path.of(path).getParent()) != null) {
      final RegisteredProject project = projects.get(test.toString());
      if (project != null) {
        return project;
      }

      path = test.toString();
    }

    return null;
  }

  /**
   * Creates RegisteredProject and caches it.
   *
   * @param config project config
   * @param folder base folder of project
   * @param updated whether this configuration was updated
   * @param detected whether this is automatically detected or explicitly defined project
   * @return project
   * @throws ServerException when path for project is undefined
   */
  RegisteredProject putProject(
      ProjectConfig config, FolderEntry folder, boolean updated, boolean detected)
      throws ServerException {

    final RegisteredProject project =
        new RegisteredProject(folder, config, updated, detected, this.projectTypeRegistry);
    projects.put(project.getPath(), project);

    return project;
  }

  /**
   * Removes all projects on and under the incoming path.
   *
   * @param path from where to remove
   * @throws ServerException
   */
  void removeProjects(String path) throws ServerException {

    List<RegisteredProject> removed = new ArrayList<>();
    Optional.ofNullable(projects.remove(path)).ifPresent(removed::add);
    getProjects(path).forEach(p -> Optional.ofNullable(projects.remove(p)).ifPresent(removed::add));

    removed.forEach(
        registeredProject ->
            eventService.publish(new ProjectDeletedEvent(registeredProject.getPath())));
  }

  /*  ------------------------------------------ */
  /*   to use from extension                     */
  /*  ------------------------------------------ */

  /**
   * Extension writer should call this method to apply changes which (supposedly) change Attributes
   * defined with particular Project Type If incoming Project Type is primary and: - If the folder
   * located on projectPath is a Project, its Primary PT will be converted to incoming PT - If the
   * folder located on projectPath is NOT a Project the folder will be converted to "detected"
   * Project with incoming Primary PT If incoming Project Type is mixin and: - If the folder located
   * on projectPath is a Project, this PT will be added (if not already there) to its Mixin PTs - If
   * the folder located on projectPath is NOT a Project - ConflictException will be thrown For
   * example: - extension code knows that particular file content is used by Value Provider so this
   * method should be called when content of this file changed to check and update attributes. OR If
   * Extension writer wants to force initializing folder to be Project For example: - extension code
   * knows that particular folder inside should (or may) be treated as sub-project of same as
   * "parent" project type
   *
   * @param projectPath absolute project path
   * @param type type to be updated or added
   * @param asMixin whether the type supposed to be mixin (true) or primary (false)
   * @return refreshed project
   * @throws ConflictException
   * @throws NotFoundException
   * @throws ServerException
   */
  public RegisteredProject setProjectType(String projectPath, String type, boolean asMixin)
      throws ConflictException, NotFoundException, ServerException {
    final RegisteredProject project = getProject(projectPath);
    final NewProjectConfig conf;
    List<String> newMixins = new ArrayList<>();

    if (project == null) {
      if (asMixin) {
        throw new ConflictException(
            "Can not assign as mixin type '"
                + type
                + "' since the "
                + projectPath
                + " is not a project.");
      } else {

        final String path = absolutizePath(projectPath);
        final String name = Path.of(projectPath).getName();

        conf = new NewProjectConfigImpl(path, type, newMixins, name, name, null, null, null);

        return putProject(conf, root.getChildFolder(path), true, true);
      }
    } else {
      newMixins = project.getMixins();
      String newType = project.getType();
      if (asMixin) {
        if (!newMixins.contains(type)) {
          newMixins.add(type);
        }
      } else {
        newType = type;
      }

      conf =
          new NewProjectConfigImpl(
              project.getPath(),
              newType,
              newMixins,
              project.getName(),
              project.getDescription(),
              project.getAttributes(),
              null,
              project.getSource());

      return putProject(conf, project.getBaseFolder(), true, project.isDetected());
    }
  }

  /**
   * Extension writer should call this method to apply changes which supposedly make the Project no
   * longer have particular Project Type. In a case of removing primary project type: - if the
   * project was NOT detected BASE Project Type will be set as primary - if the project was detected
   * it will be converted back to the folder For example: - extension code knows that removing some
   * file inside project's file system will (or may) cause removing particular project type
   *
   * @param projectPath project path
   * @param type project type
   * @return refreshed project or null if such a project not found or was removed
   * @throws ConflictException
   * @throws ForbiddenException
   * @throws NotFoundException
   * @throws ServerException
   */
  public RegisteredProject removeProjectType(String projectPath, String type)
      throws ConflictException, ForbiddenException, NotFoundException, ServerException {
    final RegisteredProject project = getProject(projectPath);

    if (project == null) {
      return null;
    }

    List<String> newMixins = project.getMixins();
    String newType = project.getType();

    if (newMixins.contains(type)) {
      newMixins.remove(type);
    } else if (newType.equals(type)) {
      if (project.isDetected()) {
        projects.remove(project.getPath());
        return null;
      }

      newType = BaseProjectType.ID;
    }

    final NewProjectConfig conf =
        new NewProjectConfigImpl(
            project.getPath(),
            newType,
            newMixins,
            project.getName(),
            project.getDescription(),
            project.getAttributes(),
            null,
            project.getSource());

    return putProject(conf, project.getBaseFolder(), true, project.isDetected());
  }

  /**
   * @param path a path
   * @return absolute (with lead slash) path
   */
  static String absolutizePath(String path) {
    return (path.startsWith("/")) ? path : "/".concat(path);
  }

  /** Try to initialize projects from unconfigured folders on root. */
  private void initUnconfiguredFolders() {
    try {
      for (FolderEntry folder : root.getChildFolders()) {
        // .che folder is reserved for internal use
        if (!".che".equals(folder.getName())
            && !projects.containsKey(folder.getVirtualFile().getPath().toString())) {
          putProject(null, folder, true, true);
        }
      }
    } catch (ServerException e) {
      LOG.warn(e.getLocalizedMessage());
    }
  }

  /**
   * Fires init handlers for all the project types of incoming project.
   *
   * @param project the project
   * @throws ForbiddenException
   * @throws ConflictException
   * @throws NotFoundException
   * @throws ServerException
   */
  void fireInitHandlers(RegisteredProject project)
      throws ForbiddenException, ConflictException, NotFoundException, ServerException {
    // primary type
    fireInit(project, project.getType());

    // mixins
    for (String mixin : project.getMixins()) {
      fireInit(project, mixin);
    }
  }

  void fireInit(RegisteredProject project, String type)
      throws ForbiddenException, ConflictException, NotFoundException, ServerException {
    ProjectInitHandler projectInitHandler = handlers.getProjectInitHandler(type);
    if (projectInitHandler != null) {
      projectInitHandler.onProjectInitialized(this, project.getBaseFolder());
    }
  }

  private void checkInitializationState() {
    if (!initialized) {
      throw new IllegalStateException("Projects are not initialized yet");
    }
  }
}
